/*
 * Copyright 2021 The Closure Compiler Authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.javascript.jscomp.serialization;

import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerPass;
import com.google.javascript.jscomp.CompilerTestCase;
import com.google.javascript.rhino.Node;
import com.google.protobuf.Descriptors.FieldDescriptor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/**
 * Tests for `TypedAstSerializer`.
 *
 * <p>TODO(bradfordcsmith): Test all parts of the result `TestAst` objects. This class was added
 * when `TypedAstSerializer` was expanded to handle serialization of an AST that already contains
 * `Color`s instead of `JSType`s. The initially added test cases focus on the serialized `TypePool`,
 * which is relevant to that change.
 */
@RunWith(JUnit4.class)
public class TypedAstSerializerTest extends CompilerTestCase {

  /**
   * Externs containing only definitions for `console.log` and `console.error`.
   *
   * <p>We should not use `TestExternsBuilder` here because we want to have definitions for "log"
   * and "error" console methods and definitely nothing else. The externs generated by
   * `TestExternsBuilder` are expected to more-or-less mirror the "real" externs and to expand as
   * needed for general use in testing.
   */
  private static final Externs CONSOLE_EXTERNS =
      externs(
          lines(
              "/** @constructor */",
              "function Console() {};",
              "",
              "/**",
              " * @param {...*} var_args",
              " * @return {undefined}",
              " */",
              "Console.prototype.log = function(var_args) {};",
              "",
              "/**",
              " * @param {...*} var_args",
              " * @return {undefined}",
              " */",
              "Console.prototype.error = function(var_args) {};",
              "",
              "/** @const {!Console} */",
              "var console;",
              ""));

  // Proto fields commonly ignored in tests because hardcoding their values is brittle
  private static final FieldDescriptor OBJECT_UUID =
      ObjectTypeProto.getDescriptor().findFieldByName("uuid");

  private static final ImmutableList<FieldDescriptor> BRITTLE_TYPE_FIELDS =
      ImmutableList.of(OBJECT_UUID);

  /** Holds the serialized AST created by the last executed test method. */
  private @Nullable TypedAst testResult = null;

  @Override
  @Before
  public void setUp() throws Exception {
    super.setUp();
    // We'll be clearing the externs. See getProcessor() for an explanation.
    allowExternsChanges();
  }

  @Override
  protected CompilerPass getProcessor(final Compiler compiler) {
    return (externs, root) -> {
      // In general we avoid serializing types and properties of types that are not referenced by
      // AST nodes. In practice this avoids serializing properties and types that have been entirely
      // removed due to optimizations.
      //
      // We will simulate this behavior here by clearing the externs so only types and
      // properties that are referenced in the sources branch of the AST will be serialized.
      final Node externsRoot = compiler.getRoot().getFirstChild();
      for (Node externsScript = externsRoot.getFirstChild();
          externsScript != null;
          externsScript = externsScript.getNext()) {
        externsScript.removeChildren();
        compiler.reportChangeToChangeScope(externsScript);
      }

      final TypedAstSerializer typedAstSerializer =
          new TypedAstSerializer(
              compiler,
              SerializationOptions.builder()
                  .setIncludeDebugInfo(true)
                  .setRunValidation(true)
                  .build());
      testResult = typedAstSerializer.serializeRoots(externs, root);
    };
  }

  @Test
  public void emptySourcesNoTypeChecking() {
    disableTypeCheck();
    // No code and no type checking, so no types.
    new Tester().test("");
  }

  @Test
  public void consoleDotLogNoTypeChecking() {
    disableTypeCheck();
    // No type checking, so no types to serialize.
    new Tester().test("console.log(1);");
  }

  @Test
  public void emptySourcesWithTypeChecking() {
    enableTypeCheck();

    // No code, so no types referenced to be serialized.
    new Tester().test("");
  }

  @Test
  public void consoleDotLogWithTypeChecking() {
    enableTypeCheck();

    new Tester()
        .expectType(ObjectTypeProto.newBuilder()) // Console
        .expectTypeWithProperties(ObjectTypeProto.newBuilder(), "log") // Console.prototype
        .test("console.log(1);");
  }

  @Test
  public void emptySourcesWithColorizedAst() {
    enableTypeCheck();
    replaceTypesWithColors();

    // No code, so no types referenced to be serialized.
    new Tester().test("");
  }

  @Test
  public void consoleDotLogWithColorizedAst() {
    enableTypeCheck();
    replaceTypesWithColors();

    new Tester()
        .expectType(ObjectTypeProto.newBuilder()) // Console
        .expectTypeWithProperties(ObjectTypeProto.newBuilder(), "log") // Console.prototype
        .test("console.log(1);");
  }

  private class Tester {
    final Map<ObjectTypeProto.Builder, ImmutableSet<String>> expectedTypeToPropertyNamesMap =
        new HashMap<>();

    Tester expectType(ObjectTypeProto.Builder type) {
      this.expectedTypeToPropertyNamesMap.put(type, ImmutableSet.of());
      return this;
    }

    // pass 'ownPropertyNames' here rather than using the ObjectTypeProto.Builder to avoid needing
    // to go through the StringPool
    Tester expectTypeWithProperties(ObjectTypeProto.Builder type, String... propertyNames) {
      this.expectedTypeToPropertyNamesMap.put(type, ImmutableSet.copyOf(propertyNames));
      return this;
    }

    void test(String code) {
      testSame(CONSOLE_EXTERNS, srcs(code));

      final HashMap<String, Integer> stringPoolOffsets = new HashMap<>();
      StringPool stringPool = StringPool.fromProto(testResult.getStringPool());
      for (int i = 0; i < testResult.getStringPool().getStringsCount(); ++i) {
        stringPoolOffsets.put(stringPool.get(i), i);
      }
      final List<TypeProto> expectedTypes = new ArrayList<>();
      for (Map.Entry<ObjectTypeProto.Builder, ImmutableSet<String>> expectedTypeWithProperties :
          expectedTypeToPropertyNamesMap.entrySet()) {
        ObjectTypeProto.Builder ex = expectedTypeWithProperties.getKey();
        for (String propertyName : expectedTypeWithProperties.getValue()) {
          Preconditions.checkState(
              stringPoolOffsets.containsKey(propertyName),
              "Missing property '%s' in string pool",
              propertyName);
          ex.addOwnProperty(stringPoolOffsets.get(propertyName));
        }
        expectedTypes.add(TypeProto.newBuilder().setObject(ex).build());
      }
      final List<TypeProto> actualTypes = testResult.getTypePool().getTypeList();
      assertThat(actualTypes)
          .ignoringFieldDescriptors(BRITTLE_TYPE_FIELDS)
          .containsExactlyElementsIn(expectedTypes);
    }
  }
}
